Skip to content

Commit

Permalink
Support filtering annotations by page number or range
Browse files Browse the repository at this point in the history
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 #5937.
  • Loading branch information
robertknight committed Dec 4, 2023
1 parent 07b6fcc commit 12f61dd
Show file tree
Hide file tree
Showing 6 changed files with 168 additions and 2 deletions.
29 changes: 29 additions & 0 deletions src/sidebar/helpers/test/view-filter-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 8 additions & 1 deletion src/sidebar/helpers/view-filter.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
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;
Expand Down Expand Up @@ -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()],
Expand Down
36 changes: 36 additions & 0 deletions src/sidebar/util/page-range.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* 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, of 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;
}
}
12 changes: 11 additions & 1 deletion src/sidebar/util/search-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -128,6 +130,7 @@ export function generateFacetedFilter(
focusFilters: FocusFilter = {},
): Record<string, Facet> {
const any = [];
const page = [];
const quote = [];
const since = [];
const tag = [];
Expand All @@ -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();
Expand Down Expand Up @@ -194,6 +200,10 @@ export function generateFacetedFilter(
terms: quote,
operator: 'and',
},
page: {
terms: page,
operator: 'or',
},
since: {
terms: since,
operator: 'and',
Expand Down
75 changes: 75 additions & 0 deletions src/sidebar/util/test/page-range-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
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,
},

// 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);
});
});
});
9 changes: 9 additions & 0 deletions src/sidebar/util/test/search-filter-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down

0 comments on commit 12f61dd

Please sign in to comment.