Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow more sophisticated filtering on item lists. #1157

Merged
merged 1 commit into from
May 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions girder/girder_large_image/rest/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ def altItemFind(self, folderId, text, name, limit, offset, sort, filters=None):
text = None
except Exception as exc:
logger.warning('Failed to parse _filter_ from text field: %r', exc)
if filters:
logger.debug('Item find filters: %s', json.dumps(filters))
if recurse:
return _itemFindRecursive(
self, origItemFind, folderId, text, name, limit, offset, sort, filters)
Expand Down
100 changes: 69 additions & 31 deletions girder/girder_large_image/web_client/views/itemList.js
Original file line number Diff line number Diff line change
Expand Up @@ -219,50 +219,88 @@ wrap(ItemListWidget, 'render', function (render) {
addToRoute({filter: this._generalFilter});
};

this._unescapePhrase = (val) => {
if (val !== undefined) {
val = val.replace('\\\'', '\'').replace('\\"', '"').replace('\\\\', '\\');
}
return val;
};

this._setFilter = () => {
const val = this._generalFilter;
let filter;
const usedPhrases = {};
const columns = (this._confList() || {}).columns || [];
if (val !== undefined && val !== '' && columns.length) {
// a value can be surrounded by single or double quotes, which will
// be removed.
const quotedValue = /((?:"((?:[^\\"]|\\\\|\\")*)"|'((?:[^\\']|\\\\|\\')*)'|([^:,\s]+)))/g;
const phraseRE = new RegExp(
new RegExp('((?:' + quotedValue.source + ':|))').source +
/(-?)/.source +
quotedValue.source +
new RegExp('((?:,' + quotedValue.source + ')*)').source, 'g');
filter = [];
val.match(/"[^"]*"|'[^']*'|\S+/g).forEach((phrase) => {
if (!phrase.length || usedPhrases[phrase]) {
return;
[...val.matchAll(phraseRE)].forEach((match) => {
const coltag = this._unescapePhrase(match[5] || match[4] || match[3]);
const phrase = this._unescapePhrase(match[10] || match[9] || match[8]);
const negation = match[6] === '-';
var phrases = [phrase];
if (match[11]) {
[...match[11].matchAll(quotedValue)].forEach((submatch) => {
const subphrase = this._unescapePhrase(submatch[4] || submatch[3] || submatch[2]);
if (subphrase && subphrase.length && !phrases.includes(subphrase)) {
phrases.push(subphrase);
}
});
}
usedPhrases[phrase] = true;
if (phrase[0] === phrase.substr(phrase.length - 1) && ['"', "'"].includes(phrase[0])) {
phrase = phrase.substr(1, phrase.length - 2);
const key = `${coltag}:` + phrases.join('|||');
if (!phrases.length || usedPhrases[key]) {
return;
}
const numval = +phrase;
/* If numval is a non-zero number not in exponential notation.
* delta is the value of one for the least significant digit.
* This will be NaN if phrase is not a number. */
const delta = Math.abs(+numval.toString().replace(/\d(?=.*[1-9](0*\.|)0*$)/g, '0').replace(/[1-9]/, '1'));
// escape for regex
phrase = phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
usedPhrases[key] = true;
const clause = [];
columns.forEach((col) => {
let key;
phrases.forEach((phrase) => {
const numval = +phrase;
/* If numval is a non-zero number not in exponential
* notation, delta is the value of one for the least
* significant digit. This will be NaN if phrase is not a
* number. */
const delta = Math.abs(+numval.toString().replace(/\d(?=.*[1-9](0*\.|)0*$)/g, '0').replace(/[1-9]/, '1'));
// escape for regex
phrase = phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

if (col.type === 'record' && col.value !== 'controls') {
key = col.value;
} else if (col.type === 'metadata') {
key = 'meta.' + col.value;
}
if (key) {
clause.push({[key]: {$regex: phrase, $options: 'i'}});
if (!_.isNaN(numval)) {
clause.push({[key]: {$eq: numval}});
if (numval > 0 && delta) {
clause.push({[key]: {$gte: numval, $lt: numval + delta}});
} else if (numval < 0 && delta) {
clause.push({[key]: {$lte: numval, $gt: numval + delta}});
columns.forEach((col) => {
let key;

if (coltag && coltag !== col.value) {
// do we want to match the last .<fragment> as well?a
// do we want to be case insensitive?
return;
}
if (col.type === 'record' && col.value !== 'controls') {
key = col.value;
} else if (col.type === 'metadata') {
key = 'meta.' + col.value;
}
if (key) {
clause.push({[key]: {$regex: phrase, $options: 'i'}});
if (!_.isNaN(numval)) {
clause.push({[key]: {$eq: numval}});
if (numval > 0 && delta) {
clause.push({[key]: {$gte: numval, $lt: numval + delta}});
} else if (numval < 0 && delta) {
clause.push({[key]: {$lte: numval, $gt: numval + delta}});
}
}
}
}
});
});
filter.push({$or: clause});
if (clause.length > 0) {
filter.push(!negation ? {$or: clause} : {$nor: clause});
} else if (!negation) {
filter.push({$or: [{_no_such_value_: '_no_such_value_'}]});
}
});
if (filter.length === 0) {
filter = undefined;
Expand Down Expand Up @@ -296,7 +334,7 @@ wrap(ItemListWidget, 'render', function (render) {
func = 'before';
}
if (base.length) {
base[func]('<span class="li-item-list-filter">Filter: <input class="li-item-list-filter-input""></input></span>');
base[func]('<span class="li-item-list-filter">Filter: <input class="li-item-list-filter-input" title="All specified terms must be included. Surround with quotes to include spaces. Prefix with - to exclude that value. By default, all columns are searched. Use <column>:<value1>[,<value2>...] to require that a column match a specified value or any of a list of specified values. Column and value names can be quotes to include spaces. If <column>:-<value1>,[.<value2>] is specified, matches will exclude the list of values."></input></span>');
if (this._generalFilter) {
root.find('.li-item-list-filter-input').val(this._generalFilter);
}
Expand Down