Commit d4d8c0bf authored by Robert Knight's avatar Robert Knight

Refactor annotation filtering to improve typing and documentation

Replace the loosely typed `Checker` interface in `view-filter.js`, which
used `any` for field values, with a `Matcher` interface which is
parametrized by the type of parsed query terms/field values. In order to
do this it was necessary to make normalization of query terms/field
values part of the `Matcher` interface.

In the process, some additional simplifications/clean-up has been done:

 - Remove the `autofalse` check. This was a very minor optimization and many of
   these functions were broken in the sense that they could never return false,
   in order to short-circuit filtering, given the current types of `Annotation`
   object fields.

 - Fix a very minor bug where user query terms that contained a suffix
   of the username followed by a prefix of the display name could match.
   The query term should be tested against the username and display name
   separately.

 - Eliminate unused `field` argument to TermFilter constructor
parent 90cb2511
...@@ -80,11 +80,17 @@ describe('sidebar/helpers/view-filter', () => { ...@@ -80,11 +80,17 @@ describe('sidebar/helpers/view-filter', () => {
describe('"any" field', () => { describe('"any" field', () => {
it('finds matches in any field', () => { it('finds matches in any field', () => {
const defaultFields = {
text: '',
tags: [],
uri: 'https://example.com',
target: [{}],
};
const annotations = [ const annotations = [
{ id: 1, text: poem.tiger, target: [{}] }, { id: 1, ...defaultFields, text: poem.tiger },
{ id: 4, user: 'lion', target: [{}] }, { id: 4, ...defaultFields, user: 'lion' },
{ id: 2, user: 'Tyger', target: [{}] }, { id: 2, ...defaultFields, user: 'Tyger' },
{ id: 3, tags: ['Tyger'], target: [{}] }, { id: 3, ...defaultFields, tags: ['Tyger'] },
]; ];
const filters = { any: { terms: ['Tyger'], operator: 'and' } }; const filters = { any: { terms: ['Tyger'], operator: 'and' } };
...@@ -187,6 +193,12 @@ describe('sidebar/helpers/view-filter', () => { ...@@ -187,6 +193,12 @@ describe('sidebar/helpers/view-filter', () => {
assert.deepEqual(result, [anns[1].id, anns[2].id, anns[3].id]); assert.deepEqual(result, [anns[1].id, anns[2].id, anns[3].id]);
}); });
it('matches username and display name independently', () => {
const anns = [annotationWithUser('dean31', 'James Dean')];
const result = filterAnnotations(anns, userQuery('dean31 james'));
assert.equal(result.length, 0);
});
it('ignores display name if not set', () => { it('ignores display name if not set', () => {
const anns = [annotationWithUser('msmith')]; const anns = [annotationWithUser('msmith')];
const result = filterAnnotations(anns, userQuery('null')); const result = filterAnnotations(anns, userQuery('null'));
...@@ -231,6 +243,8 @@ describe('sidebar/helpers/view-filter', () => { ...@@ -231,6 +243,8 @@ describe('sidebar/helpers/view-filter', () => {
id: 1, id: 1,
tags: ['foo'], tags: ['foo'],
target: [{}], target: [{}],
text: '',
url: 'https://example.com',
}; };
const filters = { const filters = {
any: { any: {
......
...@@ -21,52 +21,43 @@ import * as unicodeUtils from '../util/unicode'; ...@@ -21,52 +21,43 @@ import * as unicodeUtils from '../util/unicode';
*/ */
/** /**
* @typedef Checker * A Matcher specifies how to test whether an annotation matches a query term
* @prop {(ann: Annotation) => boolean} autofalse * for a specific field.
* @prop {(ann: Annotation) => any|any[]} value *
* @prop {(term: any, value: any) => boolean} match * @template [T=string] - Type of parsed query terms and field values
* @typedef Matcher
* @prop {(ann: Annotation) => T[]} fieldValues - Extract the field values to be
* matched against a query term
* @prop {(value: T, term: T) => boolean} matches - Test whether a query term
* matches a field value. Both value and term will have been normalized using
* `normalize`.
* @prop {(val: T) => T} normalize - Normalize a parsed term or field value for
* comparison
*/ */
/** @param {Annotation} ann */
function displayName(ann) {
if (!ann.user_info) {
return '';
}
return ann.user_info.display_name || '';
}
/** /**
* Normalize a field value or query term for comparison. * Normalize a string query term or field value.
* *
* @template {string|number} T * @param {string} val
* @param {T} val
*/ */
function normalize(val) { function normalizeStr(val) {
if (typeof val !== 'string') {
return val;
}
return unicodeUtils.fold(unicodeUtils.normalize(val)).toLowerCase(); return unicodeUtils.fold(unicodeUtils.normalize(val)).toLowerCase();
} }
/** /**
* Filter that matches annotations against a single field & term. * Filter that matches annotations against a single query term.
*
* eg. "quote:foo" or "text:bar"
* *
* @template TermType
* @implements {Filter} * @implements {Filter}
*/ */
class TermFilter { class TermFilter {
/** /**
* @param {string} field - Name of field to match * @param {TermType} term
* @param {string|number} term - Query term or value. Most fields use string * @param {Matcher<TermType>} matcher
* terms, but eg. `since` is parsed into a number first.
* @param {Checker} checker - Functions for extracting term values from
* an annotation and checking whether they match a query term.
*/ */
constructor(field, term, checker) { constructor(term, matcher) {
this.field = field; this.term = matcher.normalize(term);
this.term = term; this.matcher = matcher;
this.checker = checker;
} }
/** /**
...@@ -75,18 +66,10 @@ class TermFilter { ...@@ -75,18 +66,10 @@ class TermFilter {
* @param {Annotation} ann * @param {Annotation} ann
*/ */
matches(ann) { matches(ann) {
const checker = this.checker; const matcher = this.matcher;
if (checker.autofalse && checker.autofalse(ann)) { return matcher
return false; .fieldValues(ann)
} .some(value => matcher.matches(matcher.normalize(value), this.term));
let value = checker.value(ann);
if (Array.isArray(value)) {
value = value.map(normalize);
} else {
value = normalize(value);
}
return checker.match(this.term, value);
} }
} }
...@@ -120,50 +103,45 @@ class BinaryOpFilter { ...@@ -120,50 +103,45 @@ class BinaryOpFilter {
} }
/** /**
* Functions for extracting field values from annotations and testing whether * Create a matcher that tests whether a query term appears anywhere in a
* they match a query term. * string field value.
* *
* [facet_name]: * @param {(ann: Annotation) => string[]} fieldValues
* autofalse: a function for a preliminary false match result * @return {Matcher}
* value: a function to extract to facet value for the annotation. */
* match: a function to check if the extracted value matches the facet value function stringFieldMatcher(fieldValues) {
return {
fieldValues,
matches: (value, term) => value.includes(term),
normalize: normalizeStr,
};
}
/**
* Map of field name (from a parsed query) to matcher for that field.
* *
* @type {Record<string,Checker>} * @type {Record<string, Matcher|Matcher<number>>}
*/ */
const fieldMatchers = { const fieldMatchers = {
quote: { quote: stringFieldMatcher(ann => [quote(ann) ?? '']),
autofalse: ann => (ann.references || []).length > 0,
value: ann => quote(ann) || '', /** @type {Matcher<number>} */
match: (term, value) => value.indexOf(term) > -1,
},
since: { since: {
autofalse: ann => typeof ann.updated !== 'string', fieldValues: ann => [new Date(ann.updated).valueOf()],
value: ann => new Date(ann.updated), matches: (updatedTime, age) => {
match(term, value) { const delta = (Date.now() - updatedTime) / 1000;
const delta = (Date.now() - value) / 1000; return delta <= age;
return delta <= term;
}, },
normalize: timestamp => timestamp,
}, },
tag: {
autofalse: ann => !Array.isArray(ann.tags), tag: stringFieldMatcher(ann => ann.tags),
value: ann => ann.tags, text: stringFieldMatcher(ann => [ann.text]),
match: (term, value) => value.includes(term), uri: stringFieldMatcher(ann => [ann.uri]),
}, user: stringFieldMatcher(ann => [
text: { ann.user,
autofalse: ann => typeof ann.text !== 'string', ann.user_info?.display_name ?? '',
value: ann => ann.text, ]),
match: (term, value) => value.indexOf(term) > -1,
},
uri: {
autofalse: ann => typeof ann.uri !== 'string',
value: ann => ann.uri,
match: (term, value) => value.indexOf(term) > -1,
},
user: {
autofalse: ann => typeof ann.user !== 'string',
value: ann => ann.user + ' ' + displayName(ann),
match: (term, value) => value.indexOf(term) > -1,
},
}; };
/** /**
...@@ -174,28 +152,36 @@ const fieldMatchers = { ...@@ -174,28 +152,36 @@ const fieldMatchers = {
* @return {string[]} IDs of matching annotations. * @return {string[]} IDs of matching annotations.
*/ */
export function filterAnnotations(annotations, filters) { export function filterAnnotations(annotations, filters) {
/**
* @template TermType
* @param {string} field
* @param {TermType} term
*/
const makeTermFilter = (field, term) =>
new TermFilter(
term,
// Suppress error about potential mismatch of query term type
// and what the matcher expects. We assume these match up.
/** @type {Matcher<any>} */ (fieldMatchers[field])
);
// Convert the input filter object into a filter tree, expanding "any" // Convert the input filter object into a filter tree, expanding "any"
// filters. // filters.
const fieldFilters = Object.entries(filters) const fieldFilters = Object.entries(filters)
.filter(([, filter]) => filter.terms.length > 0) .filter(([, filter]) => filter.terms.length > 0)
.map(([field, filter]) => { .map(([field, filter]) => {
const terms = filter.terms.map(normalize);
let termFilters; let termFilters;
if (field === 'any') { if (field === 'any') {
const anyFields = ['quote', 'text', 'tag', 'user']; const anyFields = ['quote', 'text', 'tag', 'user'];
termFilters = terms.map( termFilters = filter.terms.map(
term => term =>
new BinaryOpFilter( new BinaryOpFilter(
'or', 'or',
anyFields.map( anyFields.map(field => makeTermFilter(field, term))
field => new TermFilter(field, term, fieldMatchers[field])
)
) )
); );
} else { } else {
termFilters = terms.map( termFilters = filter.terms.map(term => makeTermFilter(field, term));
term => new TermFilter(field, term, fieldMatchers[field])
);
} }
return new BinaryOpFilter(filter.operator, termFilters); return new BinaryOpFilter(filter.operator, termFilters);
}); });
......
...@@ -7,33 +7,42 @@ import { useRootThread } from '../../components/hooks/use-root-thread'; ...@@ -7,33 +7,42 @@ import { useRootThread } from '../../components/hooks/use-root-thread';
import { ServiceContext } from '../../service-context'; import { ServiceContext } from '../../service-context';
import { createSidebarStore } from '../../store'; import { createSidebarStore } from '../../store';
const defaultFields = {
$orphan: false,
tags: [],
target: [{ source: 'https://example.com' }],
references: [],
uri: 'https://example.com',
user: 'acct:foo@hypothes.is',
};
const fixtures = { const fixtures = {
annotations: [ annotations: [
{ {
$orphan: false, ...defaultFields,
created: 50, created: 50,
id: '1', id: '1',
references: [],
target: [{ selector: [] }],
text: 'first annotation', text: 'first annotation',
updated: 300, updated: 300,
}, },
{ {
$orphan: false, ...defaultFields,
created: 200, created: 200,
id: '2', id: '2',
references: [],
text: 'second annotation', text: 'second annotation',
target: [{ selector: [] }],
updated: 200, updated: 200,
}, },
{ {
$orphan: false, ...defaultFields,
created: 100, created: 100,
id: '3', id: '3',
references: ['2'], references: ['2'],
text: 'reply to first annotation', text: 'reply to first annotation',
updated: 100, updated: 100,
user: 'acct:bar@hypothes.is',
}, },
], ],
}; };
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment