Commit 11234150 authored by Robert Knight's avatar Robert Knight

Support filtering annotations by page number or range

Support `page:{number}` or `page:{start}-{end}` filters in the sidebar.  When
the query is a number, it must match the page label exactly. When it is a range,
it matches the page number if:

 - The start and end of the range, and page number, are all numeric
 - The page number is between the parsed `start` and `end` points,
   inclusive

Part of https://github.com/hypothesis/client/issues/5937.
parent 6c3eb16f
......@@ -238,6 +238,35 @@ describe('sidebar/helpers/view-filter', () => {
});
});
describe('"page" field', () => {
const annotation = {
id: 1,
target: [
{
selector: [
{
type: 'PageSelector',
index: 4,
label: '5',
},
],
},
],
};
it('matches if annotation is in page range', () => {
const filters = { page: { terms: ['4-6'], operator: 'or' } };
const result = filterAnnotations([annotation], filters);
assert.deepEqual(result, [1]);
});
it('does not match if annotation is outside of page range', () => {
const filters = { page: { terms: ['6-8'], operator: 'or' } };
const result = filterAnnotations([annotation], filters);
assert.deepEqual(result, []);
});
});
it('ignores filters with no terms in the query', () => {
const annotation = {
id: 1,
......
import type { Annotation } from '../../types/api';
import { pageLabelInRange } from '../util/page-range';
import type { Facet } from '../util/search-filter';
import * as unicodeUtils from '../util/unicode';
import { quote } from './annotation-metadata';
import { quote, pageLabel } from './annotation-metadata';
type Filter = {
matches: (ann: Annotation) => boolean;
......@@ -102,6 +103,12 @@ function stringFieldMatcher(
*/
const fieldMatchers: Record<string, Matcher | Matcher<number>> = {
quote: stringFieldMatcher(ann => [quote(ann) ?? '']),
page: {
fieldValues: ann => [pageLabel(ann)?.trim() ?? ''],
matches: (pageLabel: string, pageTerm: string) =>
pageLabelInRange(pageLabel, pageTerm),
normalize: (val: string) => val.trim(),
},
since: {
fieldValues: ann => [new Date(ann.updated).valueOf()],
......
/**
* Return true if the page number `label` is within `range`.
*
* @param label - A page number such as "10", "iv"
* @param range - A page range expressed as a single page number, or a hyphen
* separated range (eg. "10-12"). Page ranges are inclusive, so the page
* range "10-12" matches "10", "11" and "12". This means there is no way to
* specify an empty range.
*/
export function pageLabelInRange(label: string, range: string): boolean {
if (range.includes('-')) {
let [start, end] = range.split('-');
if (!start) {
start = label;
}
if (!end) {
end = label;
}
const [startInt, endInt, labelInt] = [
parseInt(start),
parseInt(end),
parseInt(label),
];
if (
Number.isInteger(startInt) &&
Number.isInteger(endInt) &&
Number.isInteger(labelInt)
) {
return labelInt >= startInt && labelInt <= endInt;
} else {
return false;
}
} else {
return label === range;
}
}
......@@ -20,7 +20,9 @@ function splitTerm(term: string): [null | string, string] {
}
if (
['group', 'quote', 'since', 'tag', 'text', 'uri', 'user'].includes(filter)
['group', 'quote', 'page', 'since', 'tag', 'text', 'uri', 'user'].includes(
filter,
)
) {
const data = term.slice(filter.length + 1);
return [filter, data];
......@@ -128,6 +130,7 @@ export function generateFacetedFilter(
focusFilters: FocusFilter = {},
): Record<string, Facet> {
const any = [];
const page = [];
const quote = [];
const since = [];
const tag = [];
......@@ -145,6 +148,9 @@ export function generateFacetedFilter(
case 'quote':
quote.push(fieldValue);
break;
case 'page':
page.push(fieldValue);
break;
case 'since':
{
const time = term.slice(6).toLowerCase();
......@@ -194,6 +200,10 @@ export function generateFacetedFilter(
terms: quote,
operator: 'and',
},
page: {
terms: page,
operator: 'or',
},
since: {
terms: since,
operator: 'and',
......
import { pageLabelInRange } from '../page-range';
describe('pageLabelInRange', () => {
[
// Single item range
{
label: '10',
range: '10',
match: true,
},
{
label: '9',
range: '10',
match: false,
},
// Number in middle of range
{
label: '5',
range: '4-8',
match: true,
},
// Number at start of range
{
label: '4',
range: '4-8',
match: true,
},
// Number at end of range
{
label: '8',
range: '4-8',
match: true,
},
// Number before range
{
label: '3',
range: '4-8',
match: false,
},
// Number after range
{
label: '9',
range: '4-8',
match: false,
},
// Range unbounded at start
{
label: '5',
range: '-8',
match: true,
},
// Range unbounded at end
{
label: '5',
range: '4-',
match: true,
},
// Open range
{
label: '5',
range: '-',
match: true,
},
// Non-numeric single item
{
label: 'foo',
range: 'foo',
match: true,
},
{
label: 'foo',
range: 'bar',
match: false,
},
// Non-numeric range
{
label: 'foo',
range: 'foo-bar',
match: false,
},
].forEach(({ label, range, match }) => {
it('returns true if the label is in the page range', () => {
assert.equal(pageLabelInRange(label, range), match);
});
});
});
......@@ -162,6 +162,15 @@ describe('sidebar/util/search-filter', () => {
},
},
},
{
query: 'page:5-10',
expectedFilter: {
page: {
operator: 'or',
terms: ['5-10'],
},
},
},
].forEach(({ query, expectedFilter }) => {
it('parses a search query', () => {
const filter = searchFilter.generateFacetedFilter(query);
......
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