1667 lines
48 KiB
JavaScript
1667 lines
48 KiB
JavaScript
/*
|
|
***** BEGIN LICENSE BLOCK *****
|
|
|
|
Copyright © 2009 Center for History and New Media
|
|
George Mason University, Fairfax, Virginia, USA
|
|
http://zotero.org
|
|
|
|
This file is part of Zotero.
|
|
|
|
Zotero is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
Zotero is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU Affero General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with Zotero. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
***** END LICENSE BLOCK *****
|
|
*/
|
|
|
|
Zotero.Search = function(params = {}) {
|
|
Zotero.Search._super.apply(this);
|
|
|
|
this._name = null;
|
|
|
|
this._scope = null;
|
|
this._scopeIncludeChildren = null;
|
|
this._sql = null;
|
|
this._sqlParams = false;
|
|
this._maxSearchConditionID = -1;
|
|
this._conditions = {};
|
|
this._hasPrimaryConditions = false;
|
|
|
|
Zotero.Utilities.assignProps(this, params, ['name', 'libraryID']);
|
|
}
|
|
|
|
Zotero.extendClass(Zotero.DataObject, Zotero.Search);
|
|
|
|
Zotero.Search.prototype._objectType = 'search';
|
|
Zotero.Search.prototype._dataTypes = Zotero.Search._super.prototype._dataTypes.concat([
|
|
'conditions'
|
|
]);
|
|
|
|
Zotero.Search.prototype.getID = function(){
|
|
Zotero.debug('Zotero.Search.getName() is deprecated -- use Search.id');
|
|
return this._id;
|
|
}
|
|
|
|
Zotero.Search.prototype.getName = function() {
|
|
Zotero.debug('Zotero.Search.getName() is deprecated -- use Search.name');
|
|
return this.name;
|
|
}
|
|
|
|
Zotero.Search.prototype.setName = function(val) {
|
|
Zotero.debug('Zotero.Search.setName() is deprecated -- use Search.name');
|
|
this.name = val;
|
|
}
|
|
|
|
Zotero.defineProperty(Zotero.Search.prototype, 'id', {
|
|
get: function() { return this._get('id'); },
|
|
set: function(val) { return this._set('id', val); }
|
|
});
|
|
Zotero.defineProperty(Zotero.Search.prototype, 'libraryID', {
|
|
get: function() { return this._get('libraryID'); },
|
|
set: function(val) { return this._set('libraryID', val); }
|
|
});
|
|
Zotero.defineProperty(Zotero.Search.prototype, 'key', {
|
|
get: function() { return this._get('key'); },
|
|
set: function(val) { return this._set('key', val); }
|
|
});
|
|
Zotero.defineProperty(Zotero.Search.prototype, 'name', {
|
|
get: function() { return this._get('name'); },
|
|
set: function(val) { return this._set('name', val); }
|
|
});
|
|
Zotero.defineProperty(Zotero.Search.prototype, 'version', {
|
|
get: function() { return this._get('version'); },
|
|
set: function(val) { return this._set('version', val); }
|
|
});
|
|
Zotero.defineProperty(Zotero.Search.prototype, 'synced', {
|
|
get: function() { return this._get('synced'); },
|
|
set: function(val) { return this._set('synced', val); }
|
|
});
|
|
Zotero.defineProperty(Zotero.Search.prototype, 'conditions', {
|
|
get: function() { return this.getConditions(); }
|
|
});
|
|
Zotero.defineProperty(Zotero.Search.prototype, '_canHaveParent', {
|
|
value: false
|
|
});
|
|
|
|
Zotero.defineProperty(Zotero.Search.prototype, 'treeViewID', {
|
|
get: function () {
|
|
return "S" + this.id
|
|
}
|
|
});
|
|
|
|
Zotero.defineProperty(Zotero.Search.prototype, 'treeViewImage', {
|
|
get: function () {
|
|
if (Zotero.isMac) {
|
|
return `chrome://zotero-platform/content/treesource-search${Zotero.hiDPISuffix}.png`;
|
|
}
|
|
return "chrome://zotero/skin/treesource-search" + Zotero.hiDPISuffix + ".png";
|
|
}
|
|
});
|
|
|
|
Zotero.Search.prototype.loadFromRow = function (row) {
|
|
var primaryFields = this._ObjectsClass.primaryFields;
|
|
for (let i=0; i<primaryFields.length; i++) {
|
|
let col = primaryFields[i];
|
|
try {
|
|
var val = row[col];
|
|
}
|
|
catch (e) {
|
|
Zotero.debug('Skipping missing ' + this._objectType + ' field ' + col);
|
|
continue;
|
|
}
|
|
|
|
switch (col) {
|
|
case this._ObjectsClass.idColumn:
|
|
col = 'id';
|
|
break;
|
|
|
|
// Integer
|
|
case 'libraryID':
|
|
val = parseInt(val);
|
|
break;
|
|
|
|
// Integer or 0
|
|
case 'version':
|
|
val = val ? parseInt(val) : 0;
|
|
break;
|
|
|
|
// Boolean
|
|
case 'synced':
|
|
val = !!val;
|
|
break;
|
|
|
|
default:
|
|
val = val || '';
|
|
}
|
|
|
|
this['_' + col] = val;
|
|
}
|
|
|
|
this._loaded.primaryData = true;
|
|
this._clearChanged('primaryData');
|
|
this._identified = true;
|
|
}
|
|
|
|
Zotero.Search.prototype._initSave = Zotero.Promise.coroutine(function* (env) {
|
|
if (!this.name) {
|
|
throw new Error('Name not provided for saved search');
|
|
}
|
|
return Zotero.Search._super.prototype._initSave.apply(this, arguments);
|
|
});
|
|
|
|
Zotero.Search.prototype._saveData = Zotero.Promise.coroutine(function* (env) {
|
|
var isNew = env.isNew;
|
|
var options = env.options;
|
|
|
|
var searchID = this._id = this.id ? this.id : Zotero.ID.get('savedSearches');
|
|
|
|
env.sqlColumns.push(
|
|
'savedSearchName'
|
|
);
|
|
env.sqlValues.push(
|
|
{ string: this.name }
|
|
);
|
|
|
|
if (env.sqlColumns.length) {
|
|
if (isNew) {
|
|
env.sqlColumns.unshift('savedSearchID');
|
|
env.sqlValues.unshift(searchID ? { int: searchID } : null);
|
|
|
|
let placeholders = env.sqlColumns.map(() => '?').join();
|
|
let sql = "INSERT INTO savedSearches (" + env.sqlColumns.join(', ') + ") "
|
|
+ "VALUES (" + placeholders + ")";
|
|
yield Zotero.DB.queryAsync(sql, env.sqlValues);
|
|
}
|
|
else {
|
|
let sql = 'UPDATE savedSearches SET '
|
|
+ env.sqlColumns.map(x => x + '=?').join(', ') + ' WHERE savedSearchID=?';
|
|
env.sqlValues.push(searchID ? { int: searchID } : null);
|
|
yield Zotero.DB.queryAsync(sql, env.sqlValues);
|
|
}
|
|
}
|
|
|
|
if (this._changed.conditions) {
|
|
if (!isNew) {
|
|
var sql = "DELETE FROM savedSearchConditions WHERE savedSearchID=?";
|
|
yield Zotero.DB.queryAsync(sql, this.id);
|
|
}
|
|
|
|
var i = 0;
|
|
var sql = "INSERT INTO savedSearchConditions "
|
|
+ "(savedSearchID, searchConditionID, condition, operator, value, required) "
|
|
+ "VALUES (?,?,?,?,?,?)";
|
|
for (let id in this._conditions) {
|
|
let condition = this._conditions[id];
|
|
|
|
// Convert condition and mode to "condition[/mode]"
|
|
let conditionString = condition.mode ?
|
|
condition.condition + '/' + condition.mode :
|
|
condition.condition
|
|
|
|
var sqlParams = [
|
|
searchID,
|
|
i,
|
|
conditionString,
|
|
condition.operator ? condition.operator : null,
|
|
condition.value ? condition.value : null,
|
|
condition.required ? 1 : null
|
|
];
|
|
yield Zotero.DB.queryAsync(sql, sqlParams);
|
|
i++;
|
|
}
|
|
}
|
|
});
|
|
|
|
Zotero.Search.prototype._finalizeSave = Zotero.Promise.coroutine(function* (env) {
|
|
if (env.isNew) {
|
|
// Update library searches status
|
|
yield Zotero.Libraries.get(this.libraryID).updateSearches();
|
|
|
|
Zotero.Notifier.queue('add', 'search', this.id, env.notifierData, env.options.notifierQueue);
|
|
}
|
|
else if (!env.options.skipNotifier) {
|
|
Zotero.Notifier.queue('modify', 'search', this.id, env.notifierData, env.options.notifierQueue);
|
|
}
|
|
|
|
if (env.isNew && Zotero.Libraries.isGroupLibrary(this.libraryID)) {
|
|
var groupID = Zotero.Groups.getGroupIDFromLibraryID(this.libraryID);
|
|
var group = yield Zotero.Groups.get(groupID);
|
|
group.clearSearchCache();
|
|
}
|
|
|
|
if (!env.skipCache) {
|
|
yield this.reload();
|
|
// If new, there's no other data we don't have, so we can mark everything as loaded
|
|
if (env.isNew) {
|
|
this._markAllDataTypeLoadStates(true);
|
|
}
|
|
this._clearChanged();
|
|
}
|
|
|
|
return env.isNew ? this.id : true;
|
|
});
|
|
|
|
|
|
Zotero.Search.prototype.clone = function (libraryID) {
|
|
var s = new Zotero.Search();
|
|
s.libraryID = libraryID === undefined ? this.libraryID : libraryID;
|
|
|
|
var conditions = this.getConditions();
|
|
|
|
for (let condition of Object.values(conditions)) {
|
|
var name = condition.mode ?
|
|
condition.condition + '/' + condition.mode :
|
|
condition.condition
|
|
|
|
s.addCondition(name, condition.operator, condition.value,
|
|
condition.required);
|
|
}
|
|
|
|
return s;
|
|
};
|
|
|
|
|
|
Zotero.Search.prototype._eraseData = Zotero.Promise.coroutine(function* (env) {
|
|
Zotero.DB.requireTransaction();
|
|
|
|
var sql = "DELETE FROM savedSearchConditions WHERE savedSearchID=?";
|
|
yield Zotero.DB.queryAsync(sql, this.id);
|
|
|
|
var sql = "DELETE FROM savedSearches WHERE savedSearchID=?";
|
|
yield Zotero.DB.queryAsync(sql, this.id);
|
|
});
|
|
|
|
Zotero.Search.prototype._finalizeErase = Zotero.Promise.coroutine(function* (env) {
|
|
yield Zotero.Search._super.prototype._finalizeErase.call(this, env);
|
|
|
|
// Update library searches status
|
|
yield Zotero.Libraries.get(this.libraryID).updateSearches();
|
|
});
|
|
|
|
|
|
Zotero.Search.prototype.addCondition = function (condition, operator, value, required) {
|
|
this._requireData('conditions');
|
|
|
|
if (!Zotero.SearchConditions.hasOperator(condition, operator)){
|
|
let e = new Error("Invalid operator '" + operator + "' for condition " + condition);
|
|
e.name = "ZoteroUnknownFieldError";
|
|
throw e;
|
|
}
|
|
|
|
// Shortcut to add a condition on every table -- does not return an id
|
|
if (condition.match(/^quicksearch/)) {
|
|
var parts = Zotero.SearchConditions.parseSearchString(value);
|
|
|
|
for (let part of parts) {
|
|
this.addCondition('blockStart');
|
|
|
|
// Allow searching for exact object key
|
|
if (operator == 'contains' && Zotero.Utilities.isValidObjectKey(part.text)) {
|
|
this.addCondition('key', 'is', part.text, false);
|
|
}
|
|
|
|
if (condition == 'quicksearch-titleCreatorYear') {
|
|
this.addCondition('title', operator, part.text, false);
|
|
this.addCondition('publicationTitle', operator, part.text, false);
|
|
this.addCondition('shortTitle', operator, part.text, false);
|
|
this.addCondition('court', operator, part.text, false);
|
|
this.addCondition('year', operator, part.text, false);
|
|
}
|
|
else {
|
|
this.addCondition('field', operator, part.text, false);
|
|
this.addCondition('tag', operator, part.text, false);
|
|
this.addCondition('note', operator, part.text, false);
|
|
}
|
|
this.addCondition('creator', operator, part.text, false);
|
|
|
|
if (condition == 'quicksearch-everything') {
|
|
this.addCondition('annotation', operator, part.text, false);
|
|
|
|
if (part.inQuotes) {
|
|
this.addCondition('fulltextContent', operator, part.text, false);
|
|
}
|
|
else {
|
|
var splits = Zotero.Fulltext.semanticSplitter(part.text);
|
|
for (let split of splits) {
|
|
this.addCondition('fulltextWord', operator, split, false);
|
|
}
|
|
}
|
|
}
|
|
|
|
this.addCondition('blockEnd');
|
|
}
|
|
|
|
if (condition == 'quicksearch-titleCreatorYear') {
|
|
this.addCondition('noChildren', 'true');
|
|
}
|
|
|
|
return false;
|
|
}
|
|
// Shortcut to add a collection (which must be loaded first)
|
|
else if (condition == 'collectionID') {
|
|
let {libraryID, key} = Zotero.Collections.getLibraryAndKeyFromID(value);
|
|
if (!key) {
|
|
let msg = "Collection " + value + " not found";
|
|
Zotero.debug(msg, 2);
|
|
Components.utils.reportError(msg);
|
|
return;
|
|
}
|
|
if (this.libraryID && libraryID != this.libraryID) {
|
|
Zotero.logError(new Error("Collection " + value + " is in different library"));
|
|
return;
|
|
}
|
|
return this.addCondition('collection', operator, key, required);
|
|
}
|
|
// Shortcut to add a saved search (which must be loaded first)
|
|
else if (condition == 'savedSearchID') {
|
|
let {libraryID, key} = Zotero.Searches.getLibraryAndKeyFromID(value);
|
|
if (!key) {
|
|
let msg = "Saved search " + value + " not found";
|
|
Zotero.debug(msg, 2);
|
|
Components.utils.reportError(msg);
|
|
return;
|
|
}
|
|
if (this.libraryID && libraryID != this.libraryID) {
|
|
Zotero.logError(new Error("Collection " + value + " is in different library"));
|
|
return;
|
|
}
|
|
return this.addCondition('savedSearch', operator, key, required);
|
|
}
|
|
// Parse old-style collection/savedSearch conditions ('0_ABCD2345' -> 'ABCD2345')
|
|
else if (condition == 'collection' || condition == 'savedSearch') {
|
|
if (value.includes('_')) {
|
|
Zotero.logError(`'condition' value '${value}' should be an object key`);
|
|
let [_, objKey] = value.split('_');
|
|
value = objKey;
|
|
}
|
|
}
|
|
|
|
var searchConditionID = ++this._maxSearchConditionID;
|
|
|
|
let mode;
|
|
[condition, mode] = Zotero.SearchConditions.parseCondition(condition);
|
|
|
|
if (typeof value == 'string') value = value.normalize();
|
|
|
|
this._conditions[searchConditionID] = {
|
|
id: searchConditionID,
|
|
condition: condition,
|
|
mode: mode,
|
|
operator: operator,
|
|
value: value,
|
|
required: !!required
|
|
};
|
|
|
|
this._sql = null;
|
|
this._sqlParams = false;
|
|
this._markFieldChange('conditions', this._conditions);
|
|
this._changed.conditions = true;
|
|
|
|
return searchConditionID;
|
|
}
|
|
|
|
|
|
/*
|
|
* Sets scope of search to the results of the passed Search object
|
|
*/
|
|
Zotero.Search.prototype.setScope = function (searchObj, includeChildren) {
|
|
this._scope = searchObj;
|
|
this._scopeIncludeChildren = includeChildren;
|
|
}
|
|
|
|
|
|
/**
|
|
* @param {Number} searchConditionID
|
|
* @param {String} condition
|
|
* @param {String} operator
|
|
* @param {String} value
|
|
* @param {Boolean} [required]
|
|
* @return {Promise}
|
|
*/
|
|
Zotero.Search.prototype.updateCondition = function (searchConditionID, condition, operator, value, required) {
|
|
this._requireData('conditions');
|
|
|
|
if (typeof this._conditions[searchConditionID] == 'undefined'){
|
|
throw new Error('Invalid searchConditionID ' + searchConditionID);
|
|
}
|
|
|
|
if (!Zotero.SearchConditions.hasOperator(condition, operator)){
|
|
let e = new Error("Invalid operator '" + operator + "' for condition " + condition);
|
|
e.name = "ZoteroUnknownFieldError";
|
|
throw e;
|
|
}
|
|
|
|
var [condition, mode] = Zotero.SearchConditions.parseCondition(condition);
|
|
|
|
if (typeof value == 'string') value = value.normalize();
|
|
|
|
this._conditions[searchConditionID] = {
|
|
id: parseInt(searchConditionID),
|
|
condition: condition,
|
|
mode: mode,
|
|
operator: operator,
|
|
value: value,
|
|
required: !!required
|
|
};
|
|
|
|
this._sql = null;
|
|
this._sqlParams = false;
|
|
this._markFieldChange('conditions', this._conditions);
|
|
this._changed.conditions = true;
|
|
}
|
|
|
|
|
|
Zotero.Search.prototype.removeCondition = function (searchConditionID) {
|
|
this._requireData('conditions');
|
|
|
|
if (typeof this._conditions[searchConditionID] == 'undefined'){
|
|
throw new Error('Invalid searchConditionID ' + searchConditionID + ' in removeCondition()');
|
|
}
|
|
|
|
delete this._conditions[searchConditionID];
|
|
this._maxSearchConditionID--;
|
|
this._markFieldChange('conditions', this._conditions);
|
|
this._changed.conditions = true;
|
|
}
|
|
|
|
|
|
/*
|
|
* Returns an array with 'condition', 'operator', 'value', 'required'
|
|
* for the given searchConditionID
|
|
*/
|
|
Zotero.Search.prototype.getCondition = function(searchConditionID){
|
|
this._requireData('conditions');
|
|
return this._conditions[searchConditionID];
|
|
}
|
|
|
|
|
|
/*
|
|
* Returns an object of conditions/operator/value sets used in the search,
|
|
* indexed by searchConditionID
|
|
*/
|
|
Zotero.Search.prototype.getConditions = function(){
|
|
this._requireData('conditions');
|
|
var conditions = {};
|
|
for (let id in this._conditions) {
|
|
let condition = this._conditions[id];
|
|
conditions[id] = {
|
|
id: id,
|
|
condition: condition.condition,
|
|
mode: condition.mode,
|
|
operator: condition.operator,
|
|
value: condition.value,
|
|
required: condition.required
|
|
};
|
|
}
|
|
return conditions;
|
|
}
|
|
|
|
|
|
Zotero.Search.prototype.hasPostSearchFilter = function() {
|
|
this._requireData('conditions');
|
|
for (let i of Object.values(this._conditions)) {
|
|
if (i.condition == 'fulltextContent'){
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
|
|
/**
|
|
* Run the search and return an array of item ids for results
|
|
*
|
|
* @param {Boolean} [asTempTable=false]
|
|
* @return {Promise}
|
|
*/
|
|
Zotero.Search.prototype.search = Zotero.Promise.coroutine(function* (asTempTable) {
|
|
var tmpTable;
|
|
|
|
// Mark conditions as loaded
|
|
// TODO: Necessary?
|
|
if (!this._identified) {
|
|
this._requireData('conditions');
|
|
}
|
|
try {
|
|
if (!this._sql){
|
|
yield this._buildQuery();
|
|
}
|
|
|
|
// Default to 'all' mode
|
|
var joinMode = 'all';
|
|
|
|
// Set some variables for conditions to avoid further lookups
|
|
for (let condition of Object.values(this._conditions)) {
|
|
switch (condition.condition) {
|
|
case 'joinMode':
|
|
if (condition.operator == 'any') {
|
|
joinMode = 'any';
|
|
}
|
|
break;
|
|
|
|
case 'fulltextContent':
|
|
var fulltextContent = true;
|
|
break;
|
|
|
|
case 'includeParentsAndChildren':
|
|
if (condition.operator == 'true') {
|
|
var includeParentsAndChildren = true;
|
|
}
|
|
break;
|
|
|
|
case 'includeParents':
|
|
if (condition.operator == 'true') {
|
|
var includeParents = true;
|
|
}
|
|
break;
|
|
|
|
case 'includeChildren':
|
|
if (condition.operator == 'true') {
|
|
var includeChildren = true;
|
|
}
|
|
break;
|
|
|
|
case 'blockStart':
|
|
var hasQuicksearch = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Run a subsearch to define the superset of possible results
|
|
if (this._scope) {
|
|
// If subsearch has post-search filter, run and insert ids into temp table
|
|
if (this._scope.hasPostSearchFilter()) {
|
|
var ids = yield this._scope.search();
|
|
if (!ids) {
|
|
return [];
|
|
}
|
|
tmpTable = yield Zotero.Search.idsToTempTable(ids);
|
|
}
|
|
// Otherwise, just copy to temp table directly
|
|
else {
|
|
tmpTable = "tmpSearchResults_" + Zotero.randomString(8);
|
|
var sql = "CREATE TEMPORARY TABLE " + tmpTable + " AS "
|
|
+ (yield this._scope.getSQL());
|
|
yield Zotero.DB.queryAsync(sql, yield this._scope.getSQLParams());
|
|
var sql = "CREATE INDEX " + tmpTable + "_itemID ON " + tmpTable + "(itemID)";
|
|
yield Zotero.DB.queryAsync(sql);
|
|
}
|
|
|
|
// Search ids in temp table
|
|
var sql = "SELECT GROUP_CONCAT(itemID) FROM items WHERE itemID IN (" + this._sql + ") "
|
|
+ "AND ("
|
|
+ "itemID IN (SELECT itemID FROM " + tmpTable + ")";
|
|
|
|
if (this._scopeIncludeChildren) {
|
|
sql += " OR itemID IN (SELECT itemID FROM itemAttachments"
|
|
+ " WHERE parentItemID IN (SELECT itemID FROM " + tmpTable + ")) OR "
|
|
+ "itemID IN (SELECT itemID FROM itemNotes"
|
|
+ " WHERE parentItemID IN (SELECT itemID FROM " + tmpTable + "))";
|
|
}
|
|
sql += ")";
|
|
|
|
var res = yield Zotero.DB.valueQueryAsync(sql, this._sqlParams);
|
|
var ids = res ? res.split(",").map(id => parseInt(id)) : [];
|
|
/*
|
|
// DEBUG: Should this be here?
|
|
//
|
|
if (!ids) {
|
|
Zotero.DB.query("DROP TABLE " + tmpTable);
|
|
Zotero.DB.commitTransaction();
|
|
return false;
|
|
}
|
|
*/
|
|
}
|
|
// Or just run main search
|
|
else {
|
|
var ids = yield Zotero.DB.columnQueryAsync(this._sql, this._sqlParams);
|
|
}
|
|
|
|
//Zotero.debug('IDs from main search or subsearch: ');
|
|
//Zotero.debug(ids);
|
|
|
|
//Zotero.debug('Join mode: ' + joinMode);
|
|
|
|
// Filter results with fulltext search
|
|
//
|
|
// If join mode ALL, return the (intersection of main and fulltext word search)
|
|
// filtered by fulltext content
|
|
//
|
|
// If join mode ANY or there's a quicksearch (which we assume
|
|
// fulltextContent is part of), return the union of the main search and
|
|
// (a separate fulltext word search filtered by fulltext content)
|
|
for (let condition of Object.values(this._conditions)){
|
|
if (condition['condition']=='fulltextContent'){
|
|
var fulltextWordIntersectionFilter = (val, index, array) => !!hash[val];
|
|
var fulltextWordIntersectionConditionFilter = function(val, index, array) {
|
|
return hash[val] ?
|
|
(condition.operator == 'contains') :
|
|
(condition.operator == 'doesNotContain');
|
|
};
|
|
|
|
// Regexp mode -- don't use fulltext word index
|
|
if (condition.mode && condition.mode.startsWith('regexp')) {
|
|
// In an ANY search with other conditions that alter the results, only bother
|
|
// scanning items that haven't already been found by the main search, as long as
|
|
// they're in the right library
|
|
if (joinMode == 'any' && this._hasPrimaryConditions) {
|
|
if (!tmpTable) {
|
|
tmpTable = yield Zotero.Search.idsToTempTable(ids);
|
|
}
|
|
|
|
var sql = "SELECT GROUP_CONCAT(itemID) FROM items WHERE "
|
|
+ "itemID NOT IN (SELECT itemID FROM " + tmpTable + ")";
|
|
if (this.libraryID) {
|
|
sql += " AND libraryID=" + parseInt(this.libraryID);
|
|
}
|
|
|
|
var res = yield Zotero.DB.valueQueryAsync(sql);
|
|
var scopeIDs = res ? res.split(",").map(id => parseInt(id)) : [];
|
|
}
|
|
// If an ALL search, scan only items from the main search
|
|
else {
|
|
var scopeIDs = ids;
|
|
}
|
|
}
|
|
// If not regexp mode, run a new search against the fulltext word
|
|
// index for words in this phrase
|
|
else {
|
|
Zotero.debug('Running subsearch against fulltext word index');
|
|
var s = new Zotero.Search();
|
|
if (this.libraryID) {
|
|
s.libraryID = this.libraryID;
|
|
}
|
|
|
|
// Add any necessary conditions to the fulltext word search --
|
|
// those that are required in an ANY search and any outside the
|
|
// quicksearch in an ALL search
|
|
for (let c of Object.values(this._conditions)) {
|
|
if (c.condition == 'blockStart') {
|
|
var inQS = true;
|
|
continue;
|
|
}
|
|
else if (c.condition == 'blockEnd') {
|
|
inQS = false;
|
|
continue;
|
|
}
|
|
else if (c.condition == 'fulltextContent' || inQS) {
|
|
continue;
|
|
}
|
|
else if (joinMode == 'any' && !c.required) {
|
|
continue;
|
|
}
|
|
s.addCondition(c.condition, c.operator, c.value);
|
|
}
|
|
|
|
var splits = Zotero.Fulltext.semanticSplitter(condition.value);
|
|
for (let split of splits){
|
|
s.addCondition('fulltextWord', condition.operator, split);
|
|
}
|
|
var fulltextWordIDs = yield s.search();
|
|
|
|
//Zotero.debug("Fulltext word IDs");
|
|
//Zotero.debug(fulltextWordIDs);
|
|
|
|
// If ALL mode, set intersection of main search and fulltext word index
|
|
// as the scope for the fulltext content search
|
|
if (joinMode == 'all' && !hasQuicksearch) {
|
|
var hash = {};
|
|
for (let i=0; i<fulltextWordIDs.length; i++) {
|
|
hash[fulltextWordIDs[i]] = true;
|
|
}
|
|
|
|
if (ids) {
|
|
var scopeIDs = ids.filter(fulltextWordIntersectionFilter);
|
|
}
|
|
else {
|
|
var scopeIDs = [];
|
|
}
|
|
}
|
|
// If ANY mode, just use fulltext word index hits for content search,
|
|
// since the main results will be added in below
|
|
else {
|
|
var scopeIDs = fulltextWordIDs;
|
|
}
|
|
}
|
|
|
|
if (scopeIDs && scopeIDs.length) {
|
|
var fulltextIDs = yield Zotero.Fulltext.findTextInItems(scopeIDs,
|
|
condition['value'], condition['mode']);
|
|
|
|
var hash = {};
|
|
for (let i=0; i<fulltextIDs.length; i++) {
|
|
hash[fulltextIDs[i].id] = true;
|
|
}
|
|
|
|
filteredIDs = scopeIDs.filter(fulltextWordIntersectionConditionFilter);
|
|
}
|
|
else {
|
|
var filteredIDs = [];
|
|
}
|
|
|
|
//Zotero.debug("Filtered IDs:")
|
|
//Zotero.debug(filteredIDs);
|
|
|
|
// If join mode ANY, add any new items from the fulltext content
|
|
// search to the main search results
|
|
//
|
|
// We only do this if there are primary conditions that alter the
|
|
// main search, since otherwise all items will match
|
|
if (this._hasPrimaryConditions && (joinMode == 'any' || hasQuicksearch)) {
|
|
//Zotero.debug("Adding filtered IDs to main set");
|
|
for (let i=0; i<filteredIDs.length; i++) {
|
|
let id = filteredIDs[i];
|
|
if (ids.indexOf(id) == -1) {
|
|
ids.push(id);
|
|
}
|
|
}
|
|
}
|
|
else {
|
|
//Zotero.debug("Replacing main set with filtered IDs");
|
|
ids = filteredIDs;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.hasPostSearchFilter() &&
|
|
(includeParentsAndChildren || includeParents || includeChildren)) {
|
|
var tmpTable = yield Zotero.Search.idsToTempTable(ids);
|
|
|
|
if (includeParentsAndChildren || includeParents) {
|
|
//Zotero.debug("Adding parent items to result set");
|
|
var sql = "SELECT parentItemID FROM itemAttachments "
|
|
+ "WHERE itemID IN (SELECT itemID FROM " + tmpTable + ") "
|
|
+ " AND parentItemID IS NOT NULL "
|
|
+ "UNION SELECT parentItemID FROM itemNotes "
|
|
+ "WHERE itemID IN (SELECT itemID FROM " + tmpTable + ")"
|
|
+ " AND parentItemID IS NOT NULL";
|
|
}
|
|
|
|
if (includeParentsAndChildren || includeChildren) {
|
|
//Zotero.debug("Adding child items to result set");
|
|
var childrenSQL = "SELECT itemID FROM itemAttachments WHERE "
|
|
+ "parentItemID IN (SELECT itemID FROM " + tmpTable + ") UNION "
|
|
+ "SELECT itemID FROM itemNotes WHERE parentItemID IN "
|
|
+ "(SELECT itemID FROM " + tmpTable + ")";
|
|
|
|
if (includeParentsAndChildren || includeParents) {
|
|
sql += " UNION " + childrenSQL;
|
|
}
|
|
else {
|
|
sql = childrenSQL;
|
|
}
|
|
}
|
|
|
|
sql = "SELECT GROUP_CONCAT(itemID) FROM items WHERE itemID IN (" + sql + ")";
|
|
var res = yield Zotero.DB.valueQueryAsync(sql);
|
|
var parentChildIDs = res ? res.split(",").map(id => parseInt(id)) : [];
|
|
|
|
// Add parents and children to main ids
|
|
for (let id of parentChildIDs) {
|
|
if (!ids.includes(id)) {
|
|
ids.push(id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
finally {
|
|
if (tmpTable && !asTempTable) {
|
|
yield Zotero.DB.queryAsync("DROP TABLE IF EXISTS " + tmpTable);
|
|
}
|
|
}
|
|
|
|
//Zotero.debug('Final result set');
|
|
//Zotero.debug(ids);
|
|
|
|
if (!ids || !ids.length) {
|
|
return [];
|
|
}
|
|
|
|
if (asTempTable) {
|
|
return Zotero.Search.idsToTempTable(ids);
|
|
}
|
|
return ids;
|
|
});
|
|
|
|
|
|
/**
|
|
* Populate the object's data from an API JSON data object
|
|
*
|
|
* If this object is identified (has an id or library/key), loadAll() must have been called.
|
|
*/
|
|
Zotero.Search.prototype.fromJSON = function (json) {
|
|
if (!json.name) {
|
|
throw new Error("'name' property not provided for search");
|
|
}
|
|
this.name = json.name;
|
|
|
|
Object.keys(this.getConditions()).forEach(id => this.removeCondition(id));
|
|
for (let i = 0; i < json.conditions.length; i++) {
|
|
let condition = json.conditions[i];
|
|
this.addCondition(
|
|
condition.condition,
|
|
condition.operator,
|
|
condition.value
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
Zotero.Search.prototype.toJSON = function (options = {}) {
|
|
var env = this._preToJSON(options);
|
|
var mode = env.mode;
|
|
|
|
var obj = env.obj = {};
|
|
obj.key = this.key;
|
|
obj.version = this.version;
|
|
obj.name = this.name;
|
|
var conditions = this.getConditions();
|
|
obj.conditions = Object.keys(conditions)
|
|
.map(x => ({
|
|
condition: conditions[x].condition
|
|
+ (conditions[x].mode !== false ? "/" + conditions[x].mode : ""),
|
|
operator: conditions[x].operator,
|
|
// TODO: Change joinMode to use 'is' + 'any' instead of operator 'any'?
|
|
value: conditions[x].value ? conditions[x].value : ""
|
|
}));
|
|
return this._postToJSON(env);
|
|
}
|
|
|
|
|
|
/*
|
|
* Get the SQL string for the search
|
|
*/
|
|
Zotero.Search.prototype.getSQL = Zotero.Promise.coroutine(function* () {
|
|
if (!this._sql) {
|
|
yield this._buildQuery();
|
|
}
|
|
return this._sql;
|
|
});
|
|
|
|
|
|
Zotero.Search.prototype.getSQLParams = Zotero.Promise.coroutine(function* () {
|
|
if (!this._sql) {
|
|
yield this._buildQuery();
|
|
}
|
|
return this._sqlParams;
|
|
});
|
|
|
|
|
|
/*
|
|
* Batch insert
|
|
*/
|
|
Zotero.Search.idsToTempTable = Zotero.Promise.coroutine(function* (ids) {
|
|
var tmpTable = "tmpSearchResults_" + Zotero.randomString(8);
|
|
|
|
Zotero.debug(`Creating ${tmpTable} with ${ids.length} item${ids.length != 1 ? 's' : ''}`);
|
|
var sql = "CREATE TEMPORARY TABLE " + tmpTable;
|
|
if (ids.length) {
|
|
sql += " AS "
|
|
+ "WITH cte(itemID) AS ("
|
|
+ "VALUES " + ids.map(id => "(" + parseInt(id) + ")").join(',')
|
|
+ ") "
|
|
+ "SELECT * FROM cte";
|
|
}
|
|
else {
|
|
sql += " (itemID INTEGER PRIMARY KEY)";
|
|
}
|
|
yield Zotero.DB.queryAsync(sql, false, { debug: false });
|
|
if (ids.length) {
|
|
yield Zotero.DB.queryAsync(`CREATE UNIQUE INDEX ${tmpTable}_itemID ON ${tmpTable}(itemID)`);
|
|
}
|
|
|
|
return tmpTable;
|
|
});
|
|
|
|
|
|
/*
|
|
* Build the SQL query for the search
|
|
*/
|
|
Zotero.Search.prototype._buildQuery = Zotero.Promise.coroutine(function* () {
|
|
this._requireData('conditions');
|
|
|
|
var sql = 'SELECT itemID FROM items';
|
|
var sqlParams = [];
|
|
// Separate ANY conditions for 'required' condition support
|
|
var anySQL = '';
|
|
var anySQLParams = [];
|
|
|
|
var conditions = [];
|
|
|
|
let lastCondition;
|
|
for (let condition of Object.values(this._conditions)) {
|
|
let name = condition.condition;
|
|
let conditionData = Zotero.SearchConditions.get(name);
|
|
|
|
// Has a table (or 'savedSearch', which doesn't have a table but isn't special)
|
|
if (conditionData.table || name == 'savedSearch' || name == 'tempTable') {
|
|
// For conditions with an inline filter using 'is'/'isNot', combine with last condition
|
|
// if the same
|
|
if (lastCondition
|
|
&& ((!lastCondition.alias && !condition.alias && name == lastCondition.name)
|
|
|| (lastCondition.alias && condition.alias && lastCondition.alias == condition.alias))
|
|
&& condition.operator.startsWith('is')
|
|
&& condition.operator == lastCondition.operator
|
|
&& conditionData.inlineFilter) {
|
|
if (!Array.isArray(lastCondition.value)) {
|
|
lastCondition.value = [lastCondition.value];
|
|
}
|
|
lastCondition.value.push(condition.value);
|
|
continue;
|
|
}
|
|
|
|
lastCondition = {
|
|
name: conditionData.name,
|
|
alias: conditionData.name != name ? name : false,
|
|
table: conditionData.table,
|
|
field: conditionData.field,
|
|
operator: condition.operator,
|
|
value: condition.value,
|
|
flags: conditionData.flags,
|
|
required: condition.required,
|
|
inlineFilter: conditionData.inlineFilter
|
|
};
|
|
conditions.push(lastCondition);
|
|
|
|
this._hasPrimaryConditions = true;
|
|
}
|
|
|
|
// Handle special conditions
|
|
else {
|
|
switch (conditionData.name) {
|
|
case 'deleted':
|
|
var deleted = condition.operator == 'true';
|
|
continue;
|
|
|
|
case 'noChildren':
|
|
var noChildren = condition.operator == 'true';
|
|
continue;
|
|
|
|
case 'includeParentsAndChildren':
|
|
var includeParentsAndChildren = condition.operator == 'true';
|
|
continue;
|
|
|
|
case 'includeParents':
|
|
var includeParents = condition.operator == 'true';
|
|
continue;
|
|
|
|
case 'includeChildren':
|
|
var includeChildren = condition.operator == 'true';
|
|
continue;
|
|
|
|
case 'unfiled':
|
|
var unfiled = condition.operator == 'true';
|
|
continue;
|
|
|
|
case 'publications':
|
|
var publications = condition.operator == 'true';
|
|
continue;
|
|
|
|
// Search subcollections
|
|
case 'recursive':
|
|
var recursive = condition.operator == 'true';
|
|
continue;
|
|
|
|
// Join mode ('any' or 'all')
|
|
case 'joinMode':
|
|
var joinMode = condition.operator.toUpperCase();
|
|
continue;
|
|
|
|
case 'fulltextContent':
|
|
// Handled in Search.search()
|
|
continue;
|
|
|
|
// For quicksearch block markers
|
|
case 'blockStart':
|
|
conditions.push({name:'blockStart'});
|
|
continue;
|
|
case 'blockEnd':
|
|
conditions.push({name:'blockEnd'});
|
|
continue;
|
|
}
|
|
|
|
throw new Error('Unhandled special condition ' + name);
|
|
}
|
|
}
|
|
|
|
// Exclude deleted items (and their child items) by default
|
|
let not = deleted ? "" : "NOT ";
|
|
let op = deleted ? "OR" : "AND";
|
|
sql += " WHERE ("
|
|
+ `itemID ${not} IN (SELECT itemID FROM deletedItems) `
|
|
+ `${op} itemID ${not}IN (SELECT itemID FROM itemNotes `
|
|
+ "WHERE parentItemID IS NOT NULL AND "
|
|
+ "parentItemID IN (SELECT itemID FROM deletedItems)) "
|
|
+ `${op} itemID ${not}IN (SELECT itemID FROM itemAttachments `
|
|
+ "WHERE parentItemID IS NOT NULL AND "
|
|
+ "parentItemID IN (SELECT itemID FROM deletedItems))"
|
|
+ ")";
|
|
|
|
if (noChildren){
|
|
sql += " AND (itemID NOT IN (SELECT itemID FROM itemNotes "
|
|
+ "WHERE parentItemID IS NOT NULL) AND itemID NOT IN "
|
|
+ "(SELECT itemID FROM itemAttachments "
|
|
+ "WHERE parentItemID IS NOT NULL))";
|
|
}
|
|
|
|
if (unfiled) {
|
|
sql += " AND (itemID NOT IN (SELECT itemID FROM collectionItems) "
|
|
// Exclude children
|
|
+ "AND itemID NOT IN "
|
|
+ "(SELECT itemID FROM itemAttachments WHERE parentItemID IS NOT NULL "
|
|
+ "UNION SELECT itemID FROM itemNotes WHERE parentItemID IS NOT NULL)"
|
|
+ ") "
|
|
// Exclude My Publications
|
|
+ "AND itemID NOT IN (SELECT itemID FROM publicationsItems)";
|
|
}
|
|
|
|
if (publications) {
|
|
sql += " AND (itemID IN (SELECT itemID FROM publicationsItems))";
|
|
}
|
|
|
|
// Limit to library search belongs to
|
|
//
|
|
// This is equivalent to adding libraryID as a search condition,
|
|
// but it works with ANY
|
|
if (this.libraryID !== null) {
|
|
sql += " AND (itemID IN (SELECT itemID FROM items WHERE libraryID=?))";
|
|
sqlParams.push(this.libraryID);
|
|
}
|
|
|
|
if (this._hasPrimaryConditions) {
|
|
sql += " AND ";
|
|
|
|
for (let condition of Object.values(conditions)){
|
|
var skipOperators = false;
|
|
var openParens = 0;
|
|
var condSQL = '';
|
|
var selectOpenParens = 0;
|
|
var condSelectSQL = '';
|
|
var condSQLParams = [];
|
|
|
|
//
|
|
// Special table handling
|
|
//
|
|
if (condition['table']){
|
|
switch (condition['table']){
|
|
default:
|
|
condSelectSQL += 'itemID '
|
|
switch (condition['operator']){
|
|
case 'isNot':
|
|
case 'doesNotContain':
|
|
condSelectSQL += 'NOT ';
|
|
break;
|
|
}
|
|
condSelectSQL += 'IN (';
|
|
selectOpenParens = 1;
|
|
condSQL += 'SELECT itemID FROM ' +
|
|
condition['table'] + ' WHERE (';
|
|
openParens = 1;
|
|
}
|
|
}
|
|
|
|
//
|
|
// Special condition handling
|
|
//
|
|
switch (condition['name']){
|
|
case 'field':
|
|
case 'datefield':
|
|
case 'numberfield':
|
|
if (condition['alias']) {
|
|
// Add base field
|
|
condSQLParams.push(
|
|
Zotero.ItemFields.getID(condition['alias'])
|
|
);
|
|
var typeFields = Zotero.ItemFields.getTypeFieldsFromBase(condition['alias']);
|
|
if (typeFields) {
|
|
condSQL += 'fieldID IN (?,';
|
|
// Add type-specific fields
|
|
for (let fieldID of typeFields) {
|
|
condSQL += '?,';
|
|
condSQLParams.push(fieldID);
|
|
}
|
|
condSQL = condSQL.substr(0, condSQL.length - 1);
|
|
condSQL += ') AND ';
|
|
}
|
|
else {
|
|
condSQL += 'fieldID=? AND ';
|
|
}
|
|
}
|
|
|
|
condSQL += "valueID IN (SELECT valueID FROM "
|
|
+ "itemDataValues WHERE ";
|
|
|
|
openParens++;
|
|
break;
|
|
|
|
case 'year':
|
|
condSQLParams.push(Zotero.ItemFields.getID('date'));
|
|
//Add base field
|
|
var dateFields = Zotero.ItemFields.getTypeFieldsFromBase('date');
|
|
if (dateFields) {
|
|
condSQL += 'fieldID IN (?,';
|
|
// Add type-specific date fields (dateEnacted, dateDecided, issueDate)
|
|
for (let fieldID of dateFields) {
|
|
condSQL += '?,';
|
|
condSQLParams.push(fieldID);
|
|
}
|
|
condSQL = condSQL.substr(0, condSQL.length - 1);
|
|
condSQL += ') AND ';
|
|
}
|
|
|
|
condSQL += "valueID IN (SELECT valueID FROM "
|
|
+ "itemDataValues WHERE ";
|
|
|
|
openParens++;
|
|
break;
|
|
|
|
case 'collection':
|
|
case 'savedSearch':
|
|
let obj;
|
|
let objLibraryID;
|
|
let objKey = condition.value;
|
|
let objectType = condition.name == 'collection' ? 'collection' : 'search';
|
|
let objectTypeClass = Zotero.DataObjectUtilities.getObjectsClassForObjectType(objectType);
|
|
|
|
// libraryID assigned on search
|
|
if (this.libraryID !== null) {
|
|
objLibraryID = this.libraryID;
|
|
}
|
|
|
|
// If search doesn't have a libraryID, check all possible libraries
|
|
// for the collection/search
|
|
if (objLibraryID === undefined) {
|
|
let foundLibraryID = false;
|
|
for (let c of Object.values(this._conditions)) {
|
|
if (c.condition == 'libraryID' && c.operator == 'is') {
|
|
foundLibraryID = true;
|
|
obj = yield objectTypeClass.getByLibraryAndKeyAsync(
|
|
c.value, objKey
|
|
);
|
|
if (obj) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (!foundLibraryID) {
|
|
Zotero.debug("WARNING: libraryID condition not found for "
|
|
+ objectType + " in search", 2);
|
|
}
|
|
}
|
|
else {
|
|
obj = yield objectTypeClass.getByLibraryAndKeyAsync(
|
|
objLibraryID, objKey
|
|
);
|
|
}
|
|
if (!obj) {
|
|
var msg = objectType.charAt(0).toUpperCase() + objectType.substr(1)
|
|
+ " " + objKey + " specified in search not found";
|
|
Zotero.debug(msg, 2);
|
|
Zotero.log(msg, 'warning', 'chrome://zotero/content/xpcom/search.js');
|
|
if (objectType == 'search') {
|
|
continue;
|
|
}
|
|
obj = {
|
|
id: 0
|
|
};
|
|
}
|
|
if (objectType == 'search' && obj == this) {
|
|
Zotero.warn(`Search "${this.name}" references itself -- skipping condition`);
|
|
continue;
|
|
}
|
|
|
|
if (objectType == 'collection') {
|
|
let ids = [obj.id];
|
|
|
|
// Search descendent collections if recursive search
|
|
if (recursive){
|
|
ids = ids.concat(obj.getDescendents(false, 'collection').map(d => d.id));
|
|
}
|
|
|
|
condSQL += 'collectionID IN (' + ids.join(', ') + ')';
|
|
}
|
|
// Saved search
|
|
else {
|
|
// Check if there are any post-search filters
|
|
var hasFilter = obj.hasPostSearchFilter();
|
|
|
|
// This is an ugly and inefficient way of doing a
|
|
// subsearch, but it's necessary if there are any
|
|
// post-search filters (e.g. fulltext scanning) in the
|
|
// subsearch
|
|
//
|
|
// DEBUG: it's possible there's a query length limit here
|
|
// or that this slows things down with large libraries
|
|
// -- should probably use a temporary table instead
|
|
if (hasFilter){
|
|
let subids = yield obj.search();
|
|
condSQL += subids.join();
|
|
}
|
|
// Otherwise just put the SQL in a subquery
|
|
else {
|
|
condSQL += "itemID ";
|
|
if (condition.operator == 'isNot') {
|
|
condSQL += "NOT ";
|
|
}
|
|
condSQL += "IN (";
|
|
condSQL += yield obj.getSQL();
|
|
let subpar = yield obj.getSQLParams();
|
|
for (let k in subpar){
|
|
condSQLParams.push(subpar[k]);
|
|
}
|
|
}
|
|
condSQL += ")";
|
|
}
|
|
|
|
skipOperators = true;
|
|
break;
|
|
|
|
case 'itemType':
|
|
condSQL += "itemTypeID IN (SELECT itemTypeID FROM itemTypesCombined WHERE ";
|
|
openParens++;
|
|
break;
|
|
|
|
case 'fileTypeID':
|
|
var ftSQL = 'SELECT mimeType FROM fileTypeMimeTypes '
|
|
+ 'WHERE fileTypeID IN ('
|
|
+ 'SELECT fileTypeID FROM fileTypes WHERE '
|
|
+ 'fileTypeID=?)';
|
|
var patterns = yield Zotero.DB.columnQueryAsync(ftSQL, { int: condition.value });
|
|
if (patterns) {
|
|
for (let str of patterns) {
|
|
condSQL += 'contentType LIKE ? OR ';
|
|
condSQLParams.push(str + '%');
|
|
}
|
|
condSQL = condSQL.substring(0, condSQL.length - 4);
|
|
}
|
|
else {
|
|
throw ("Invalid fileTypeID '" + condition.value + "' specified in search.js")
|
|
}
|
|
skipOperators = true;
|
|
break;
|
|
|
|
case 'tag':
|
|
condSQL += "tagID IN (SELECT tagID FROM tags WHERE ";
|
|
openParens++;
|
|
break;
|
|
|
|
case 'creator':
|
|
case 'lastName':
|
|
condSQL += "creatorID IN (SELECT creatorID FROM creators WHERE ";
|
|
openParens++;
|
|
break;
|
|
|
|
case 'childNote':
|
|
condSQL += "itemID IN (SELECT parentItemID FROM "
|
|
+ "itemNotes WHERE ";
|
|
openParens++;
|
|
break;
|
|
|
|
case 'fulltextWord':
|
|
condSQL += "wordID IN (SELECT wordID FROM fulltextWords "
|
|
+ "WHERE ";
|
|
openParens++;
|
|
break;
|
|
|
|
case 'tempTable':
|
|
condSQL += "itemID IN (SELECT id FROM " + condition.value + ")";
|
|
skipOperators = true;
|
|
break;
|
|
|
|
// For quicksearch blocks
|
|
case 'blockStart':
|
|
case 'blockEnd':
|
|
skipOperators = true;
|
|
break;
|
|
}
|
|
|
|
if (!skipOperators){
|
|
// Special handling for date fields
|
|
//
|
|
// Note: We assume full datetimes are already UTC and don't
|
|
// need to be handled specially
|
|
if ((condition['name']=='dateAdded' ||
|
|
condition['name']=='dateModified' ||
|
|
condition['name']=='datefield') &&
|
|
!Zotero.Date.isSQLDateTime(condition['value'])){
|
|
|
|
// TODO: document these flags
|
|
var parseDate = null;
|
|
var alt = null;
|
|
var useFreeform = null;
|
|
|
|
switch (condition['operator']){
|
|
case 'is':
|
|
case 'isNot':
|
|
var parseDate = true;
|
|
var alt = '__';
|
|
var useFreeform = true;
|
|
break;
|
|
|
|
case 'isBefore':
|
|
var parseDate = true;
|
|
var alt = '00';
|
|
var useFreeform = false;
|
|
break;
|
|
|
|
case 'isAfter':
|
|
var parseDate = true;
|
|
// '__' used here just so the > string comparison
|
|
// doesn't match dates in the specified year
|
|
var alt = '__';
|
|
var useFreeform = false;
|
|
break;
|
|
|
|
case 'isInTheLast':
|
|
var parseDate = false;
|
|
break;
|
|
|
|
default:
|
|
throw ('Invalid date field operator in search');
|
|
}
|
|
|
|
// Convert stored UTC dates to localtime
|
|
//
|
|
// It'd be nice not to deal with time zones here at all,
|
|
// but otherwise searching for the date part of a field
|
|
// stored as UTC that wraps midnight would be unsuccessful
|
|
if (condition['name']=='dateAdded' ||
|
|
condition['name']=='dateModified' ||
|
|
condition['alias']=='accessDate'){
|
|
condSQL += "DATE(" + condition['field'] + ", 'localtime')";
|
|
}
|
|
// Only use first (SQL) part of multipart dates
|
|
else {
|
|
condSQL += "SUBSTR(" + condition['field'] + ", 1, 10)";
|
|
}
|
|
|
|
if (parseDate){
|
|
var go = false;
|
|
var dateparts = Zotero.Date.strToDate(condition.value);
|
|
|
|
// Search on SQL date -- underscore is
|
|
// single-character wildcard
|
|
//
|
|
// If isBefore or isAfter, month and day fall back
|
|
// to '00' so that a search for just a year works
|
|
// (and no year will just not find anything)
|
|
var sqldate = dateparts.year ?
|
|
Zotero.Utilities.lpad(dateparts.year, '0', 4) : '____';
|
|
sqldate += '-'
|
|
sqldate += dateparts.month || dateparts.month === 0 ?
|
|
Zotero.Utilities.lpad(dateparts.month + 1, '0', 2) : alt;
|
|
sqldate += '-';
|
|
sqldate += dateparts.day ?
|
|
Zotero.Utilities.lpad(dateparts.day, '0', 2) : alt;
|
|
|
|
if (sqldate!='____-__-__'){
|
|
go = true;
|
|
|
|
switch (condition['operator']){
|
|
case 'is':
|
|
case 'isNot':
|
|
condSQL += ' LIKE ?';
|
|
break;
|
|
|
|
case 'isBefore':
|
|
condSQL += '<?';
|
|
condSQL += ' AND ' + condition['field'] +
|
|
">'0000-00-00'";
|
|
break;
|
|
|
|
case 'isAfter':
|
|
condSQL += '>?';
|
|
break;
|
|
}
|
|
|
|
condSQLParams.push({string:sqldate});
|
|
}
|
|
|
|
// Search for any remaining parts individually
|
|
if (useFreeform && dateparts['part']){
|
|
go = true;
|
|
var parts = dateparts['part'].split(' ');
|
|
for (let part of parts) {
|
|
condSQL += " AND SUBSTR(" + condition['field'] + ", 12, 100)";
|
|
condSQL += " LIKE ?";
|
|
condSQLParams.push('%' + part + '%');
|
|
}
|
|
}
|
|
|
|
// If neither part used, invalidate clause
|
|
if (!go){
|
|
condSQL += '=0';
|
|
}
|
|
}
|
|
|
|
else {
|
|
switch (condition['operator']){
|
|
case 'isInTheLast':
|
|
condSQL += ">DATE('NOW', 'localtime', ?)"; // e.g. ('NOW', '-10 DAYS')
|
|
condSQLParams.push({string: '-' + condition['value']});
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Non-date fields
|
|
else {
|
|
switch (condition.operator) {
|
|
// Cast strings as integers for < and > comparisons,
|
|
// at least until
|
|
case 'isLessThan':
|
|
case 'isGreaterThan':
|
|
condSQL += "CAST(" + condition['field'] + " AS INT)";
|
|
// Make sure either field is an integer or
|
|
// converting to an integer and back to a string
|
|
// yields the same result (i.e. it's numeric)
|
|
var opAppend = " AND (TYPEOF("
|
|
+ condition['field'] + ") = 'integer' OR "
|
|
+ "CAST("
|
|
+ "CAST(" + condition['field'] + " AS INT)"
|
|
+ " AS STRING) = " + condition['field'] + ")"
|
|
break;
|
|
|
|
default:
|
|
condSQL += condition['field'];
|
|
}
|
|
|
|
switch (condition['operator']){
|
|
case 'contains':
|
|
case 'doesNotContain': // excluded with NOT IN above
|
|
condSQL += ' LIKE ?';
|
|
// For fields with 'leftbound' flag, perform a
|
|
// leftbound search even for 'contains' condition
|
|
if (condition['flags'] &&
|
|
condition['flags']['leftbound'] &&
|
|
Zotero.Prefs.get('search.useLeftBound')) {
|
|
condSQLParams.push(condition['value'] + '%');
|
|
}
|
|
else {
|
|
condSQLParams.push('%' + condition['value'] + '%');
|
|
}
|
|
break;
|
|
|
|
case 'is':
|
|
case 'isNot': // excluded with NOT IN above
|
|
// If inline filter is available, embed value directly to get around
|
|
// the max bound parameter limit
|
|
if (condition.inlineFilter) {
|
|
let src = Array.isArray(condition.value)
|
|
? condition.value : [condition.value];
|
|
let values = [];
|
|
|
|
for (let val of src) {
|
|
val = condition.inlineFilter(val);
|
|
if (val) {
|
|
values.push(val);
|
|
}
|
|
}
|
|
|
|
if (!values.length) {
|
|
continue;
|
|
}
|
|
|
|
condSQL += values.length > 1
|
|
? ` IN (${values.join(', ')})`
|
|
: `=${values[0]}`;
|
|
}
|
|
else {
|
|
// Automatically cast values which might
|
|
// have been stored as integers
|
|
if (condition.value && typeof condition.value == 'string'
|
|
&& condition.value.match(/^[1-9]+[0-9]*$/)) {
|
|
condSQL += ' LIKE ?';
|
|
}
|
|
else if (condition.value === null) {
|
|
condSQL += ' IS NULL';
|
|
break;
|
|
}
|
|
else {
|
|
condSQL += '=?';
|
|
}
|
|
condSQLParams.push(condition['value']);
|
|
}
|
|
break;
|
|
|
|
case 'beginsWith':
|
|
condSQL += ' LIKE ?';
|
|
condSQLParams.push(condition['value'] + '%');
|
|
break;
|
|
|
|
case 'isLessThan':
|
|
condSQL += '<?';
|
|
condSQLParams.push({int:condition['value']});
|
|
condSQL += opAppend;
|
|
break;
|
|
|
|
case 'isGreaterThan':
|
|
condSQL += '>?';
|
|
condSQLParams.push({int:condition['value']});
|
|
condSQL += opAppend;
|
|
break;
|
|
|
|
// Next two only used with full datetimes
|
|
case 'isBefore':
|
|
condSQL += '<?';
|
|
condSQLParams.push({string:condition['value']});
|
|
break;
|
|
|
|
case 'isAfter':
|
|
condSQL += '>?';
|
|
condSQLParams.push({string:condition['value']});
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Close open parentheses
|
|
for (var k=openParens; k>0; k--){
|
|
condSQL += ')';
|
|
}
|
|
|
|
if (includeParentsAndChildren || includeParents) {
|
|
var parentSQL = "SELECT itemID FROM items WHERE "
|
|
+ "itemID IN (SELECT parentItemID FROM itemAttachments "
|
|
+ "WHERE itemID IN (" + condSQL + ")) "
|
|
+ "OR itemID IN (SELECT parentItemID FROM itemNotes "
|
|
+ "WHERE itemID IN (" + condSQL + ")) ";
|
|
var parentSQLParams = condSQLParams.concat(condSQLParams);
|
|
}
|
|
|
|
if (includeParentsAndChildren || includeChildren) {
|
|
var childrenSQL = "SELECT itemID FROM itemAttachments WHERE "
|
|
+ "parentItemID IN (" + condSQL + ") UNION "
|
|
+ "SELECT itemID FROM itemNotes "
|
|
+ "WHERE parentItemID IN (" + condSQL + ")";
|
|
var childSQLParams = condSQLParams.concat(condSQLParams);
|
|
}
|
|
|
|
if (includeParentsAndChildren || includeParents) {
|
|
condSQL += " UNION " + parentSQL;
|
|
condSQLParams = condSQLParams.concat(parentSQLParams);
|
|
}
|
|
|
|
if (includeParentsAndChildren || includeChildren) {
|
|
condSQL += " UNION " + childrenSQL;
|
|
condSQLParams = condSQLParams.concat(childSQLParams);
|
|
}
|
|
|
|
condSQL = condSelectSQL + condSQL;
|
|
|
|
// Close open parentheses
|
|
for (var k=selectOpenParens; k>0; k--) {
|
|
condSQL += ')';
|
|
}
|
|
|
|
// Little hack to support multiple quicksearch words
|
|
if (condition['name'] == 'blockStart') {
|
|
var inQS = true;
|
|
var qsSQL = '';
|
|
var qsParams = [];
|
|
continue;
|
|
}
|
|
else if (condition['name'] == 'blockEnd') {
|
|
inQS = false;
|
|
// Strip ' OR ' from last condition
|
|
qsSQL = qsSQL.substring(0, qsSQL.length-4);
|
|
|
|
// Add to existing quicksearch words
|
|
if (!quicksearchSQLSet) {
|
|
var quicksearchSQLSet = [];
|
|
var quicksearchParamsSet = [];
|
|
}
|
|
quicksearchSQLSet.push(qsSQL);
|
|
quicksearchParamsSet.push(qsParams);
|
|
}
|
|
else if (inQS) {
|
|
qsSQL += condSQL + ' OR ';
|
|
qsParams = qsParams.concat(condSQLParams);
|
|
}
|
|
// Keep non-required conditions separate if in ANY mode
|
|
else if (!condition['required'] && joinMode == 'ANY') {
|
|
anySQL += condSQL + ' OR ';
|
|
anySQLParams = anySQLParams.concat(condSQLParams);
|
|
}
|
|
else {
|
|
condSQL += ' AND ';
|
|
sql += condSQL;
|
|
sqlParams = sqlParams.concat(condSQLParams);
|
|
}
|
|
}
|
|
|
|
// Add on ANY conditions
|
|
if (anySQL){
|
|
sql += '(' + anySQL;
|
|
sqlParams = sqlParams.concat(anySQLParams);
|
|
sql = sql.substring(0, sql.length-4); // remove last ' OR '
|
|
sql += ')';
|
|
}
|
|
else {
|
|
sql = sql.substring(0, sql.length-5); // remove last ' AND '
|
|
}
|
|
|
|
// Add on quicksearch conditions
|
|
if (quicksearchSQLSet) {
|
|
sql = "SELECT itemID FROM items WHERE itemID IN (" + sql + ") "
|
|
+ "AND ((" + quicksearchSQLSet.join(') AND (') + "))";
|
|
|
|
for (var k=0; k<quicksearchParamsSet.length; k++) {
|
|
sqlParams = sqlParams.concat(quicksearchParamsSet[k]);
|
|
}
|
|
}
|
|
}
|
|
|
|
this._sql = sql;
|
|
this._sqlParams = sqlParams.length ? sqlParams : false;
|
|
});
|